Skip to content

Refactor/opt in surfaces - #3

Open
ShivSankalp wants to merge 3 commits into
mainfrom
refactor/opt-in-surfaces
Open

Refactor/opt in surfaces#3
ShivSankalp wants to merge 3 commits into
mainfrom
refactor/opt-in-surfaces

Conversation

@ShivSankalp

Copy link
Copy Markdown
Contributor

What does this change?

Related issue

Checklist

  • I've read the Contributing guide
  • The change is covered by tests or a runnable example (where applicable)
  • Docs / README updated if behavior or usage changed
  • I agree my contribution is licensed under the repository's MIT license

NamoID is the authorization server, the customer's MCP server is the
protected resource, and an MCP host is the OAuth client. This covers the
resource-server half: RFC 9728 metadata, audience-bound token
verification, and per-tool scope enforcement. No NamoID credentials are
needed, because a resource server only consumes public discovery metadata
and JWKS.

namoid.mcp imports no MCP framework, so it also backs the official MCP
Python SDK, a bare Starlette app, or a test. The framework appears only in
namoid.mcp.fastmcp. Both arrive as extras so the base install is
unaffected.

The FastMCP adapter wraps the shared core rather than subclassing
FastMCP's JWTVerifier, which avoids two failure modes by construction:

- JWTVerifier has no opinion on token_use, so an ID token for the same
  audience would be accepted as an MCP API token.
- JWTVerifier leaves AccessToken.subject unset, which would make every
  caller look like one anonymous principal and silently apply per-user
  checks to a shared identity.

require_namoid_scopes is preferred over FastMCP's require_scopes, which
filters a tool out of tools/list rather than challenging, leaving the host
unable to ask the user to approve it. Incremental authorization needs the
tool to stay visible and answer with insufficient_scope.

The provider also publishes the issuer verbatim. RemoteAuthProvider stores
authorization servers as pydantic AnyHttpUrl, which appends a trailing
slash to a bare authority; RFC 8414 compares issuer identifiers exactly,
so a strict client would see a mismatch.

Also caches JWKS with refetch on an unrecognised kid, bounded by a
cooldown so invented key IDs cannot be turned into traffic against the
issuer, and adds a CI workflow, which this repository had none of.
Hosted Auth redirects the user to a branded NamoID sign-in page and
returns a one-time code. The Client ID resolves the application, its
environment, and its Hosted Auth domain, so there is no issuer or
application UUID to configure.

NamoIDClient and AsyncNamoIDClient cover the whole flow: auth config,
PKCE transactions, hosted URL construction, code exchange, refresh,
server-side token validation, and session revocation. Both share one
definition of every request and one response parser, so the sync and
async surfaces cannot drift apart.

Pure pieces live in namoid.hosted_auth, so an application that wants to
drive the redirect itself does not have to construct a client.

Every endpoint and payload shape was taken from the running service
rather than assumed, which surfaced three fields the JavaScript SDK
does not model: user_id on the token response, support_email on the auth
config, and the /v1/auth/refresh endpoint, which has no method in
@namoidhq/js even though an example relies on it.

httpx moves from the extras to a package dependency, since making HTTP
requests is now the package's core job rather than something only the MCP
extra needed.

Failures raise NamoIDError carrying status, the API's own error code where
one is returned, and the parsed detail.
Importing namoid loaded the Hosted Auth client eagerly, so an MCP-only
server paid for code it never asked for. The top-level names now resolve
on first use (PEP 562), which gives three guarantees:

- Importing namoid loads neither surface, and neither httpx nor joserfc.
- A Hosted Auth application never imports the MCP code or needs its extra.
- An MCP server never imports the Hosted Auth client.

dir() and __all__ still list the full surface and a TYPE_CHECKING block
keeps every export resolvable, so laziness costs nothing in editor or
type-checker support.

The MCP errors now subclass NamoIDError, so one except catches both
surfaces. The error module is stdlib-only, so sharing it couples no
dependency, and the message a resource server surfaces is unchanged.

tests/test_modularity.py enforces the boundaries rather than leaving them
as an intention. Each test runs in a fresh interpreter, because
sys.modules is process-global and an earlier import in the session would
mask exactly what is being checked. The strictest one replaces
builtins.__import__ and fails if the MCP core so much as touches fastmcp,
starlette, mcp, or the Hosted Auth modules.

This also fixed the same coupling in the test suite itself: conftest
imported joserfc at module scope, so a base-only install could not even
collect the Hosted Auth tests. The suite now degrades by install: 31
tests on the base, 59 with the mcp extra, 73 with fastmcp.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant